[電話予約の無人化]Amazon Connect + GPT-4 JSONモード + Whisperで、1回の発話から予約情報(日付,時間など)を抽出
はじめに
Amazon Connect + GPT-4 JSONモード + Whisperで、1回の発話から予約情報(電話番号,日時,名前,人数)を正しく抽出できるか検証しました。
コールセンターでは、有人対応から無人対応に変更したいニーズが増えているように思います。
電話予約の無人対応を想定し、1回の発話で、下記の5つの予約情報を抽出できるか確認します。
- お名前
- 電話番号
- 予約日
- 予約時間
- 人数
発話で予約情報を抽出する方法として、GPT-4 Turbo のJSONモードを利用します。
JSONモードの詳細は、下記を参照ください。
例えば、「名前はクラスメソッドで、電話番号は09011111111。来週の火曜日の19時に4名で予約できますか?」というテキストの場合、予約情報を下記のようにJSON形式で抽出が可能です。
{ "name": "クラスメソッド", "phone_number": "09011111111", "date": "20231128", "time": "1900", "number": "4", // ~省略~ }
発話した日付が2023年11月20日なので、来週の火曜日は、11月28日になります。
注意点として、JSONモードでは、出力データが正しいJSON形式であることは保証されますが、そのデータが特定のスキーマに一致するかどうかは保証されません
ちなみに、発話から個人情報の聞き取りは、下記で試しました。
構成
構成としては、下記の通りです。
Connectのフローの詳細は下記の通りです。
- コンタクトフロー内で「メディアストリーミングの開始」ブロックを使って、Kinesis Video Stream(KVS)への音声のストリーミングを開始します。
- 顧客は、予約をするために、予約に必要な情報を発話をします。
- 「顧客の入力を保存する」ブロックで、顧客が発話を止めると、ストリーミングを終了します。
- 「AWS Lambda関数を呼び出す」ブロックを使い、LambdaでKVSからデータを取得します。取得したデータをWAV形式に変換し、Whisper APIで文字起こしします。文字起こし内容から、GPT-4 Turboで予約情報を抽出します。
- 予約情報が抽出できていれば、確認のための音声出力をし、抽出できなければ、オペレーターにエスカレーション(今回は実際にエスカレーションはしません)も可能です。
以下の図は、電話での対話の流れを示しています。
上記の図の最後で、Connectが予約情報の繰り返した後は、ユーザー側でLexを使って「はい」「いいえ」やプッシュボタンによって、フローを分岐させるとよいです。
前提
- 2023年11月時点での検証内容です。今後のアップデートにより改善される可能性があります。恒久的な結果ではありません。
- 今回は、いくつかのサンプルで検証を行っただけであり、他のサンプルでも同様の結果となるとは限りません。これらの結果は一例として参照ください。
構築
Lambda
LambdaでWhisper APIやGPT-4 Turboを利用するあたり、OpenAIアカウントAPIキーの発行やOpenAIのPython向けのライブラリをLambdaにアップロードする方法などは、以下の記事を参照ください。
以下の設定を行います
- 環境変数では、OpenAIのキーを設定
- タイムアウトは、3秒から30秒に変更
- OpenAIのPython向けのライブラリをLambdaレイヤーに追加
- IAMの管理ポリシー
AmazonKinesisVideoStreamsReadOnlyAccess
を適用
from decimal import Decimal from datetime import datetime, timedelta, timezone from ebmlite import loadSchema from enum import Enum from botocore.config import Config import boto3, os, struct, json, openai openai.api_key = os.environ["API_Key"] JST = timezone(timedelta(hours=+9)) date = lambda: datetime.now(JST).strftime('%Y%m%d') time = lambda: datetime.now(JST).strftime('%H%M') current_date = date() current_time = time() def decimal_to_int(obj): if isinstance(obj, Decimal): return int(obj) class Mkv(Enum): SEGMENT = 0x18538067 CLUSTER = 0x1F43B675 SIMPLEBLOCK = 0xA3 class Ebml(Enum): EBML = 0x1A45DFA3 class KVSParser: def __init__(self, media_content): self.__stream = media_content["Payload"] self.__schema = loadSchema("matroska.xml") self.__buffer = bytearray() @property def fragments(self): return [fragment for chunk in self.__stream if (fragment := self.__parse(chunk))] def __parse(self, chunk): self.__buffer.extend(chunk) header_elements = [e for e in self.__schema.loads(self.__buffer) if e.id == Ebml.EBML.value] if header_elements: fragment_dom = self.__schema.loads(self.__buffer[:header_elements[0].offset]) self.__buffer = self.__buffer[header_elements[0].offset:] return fragment_dom def get_simple_blocks(media_content): parser = KVSParser(media_content) return [b.value for document in parser.fragments for b in next(filter(lambda c: c.id == Mkv.CLUSTER.value, next(filter(lambda s: s.id == Mkv.SEGMENT.value, document)))) if b.id == Mkv.SIMPLEBLOCK.value] def create_audio_sample(simple_blocks, margin=4): position = 0 total_length = sum(len(block) - margin for block in simple_blocks) combined_samples = bytearray(total_length) for block in simple_blocks: temp = block[margin:] combined_samples[position:position+len(temp)] = temp position += len(temp) return combined_samples def convert_bytearray_to_wav(samples): length = len(samples) channel = 1 bit_par_sample = 16 format_code = 1 sample_rate = 8000 header_size = 44 wav = bytearray(header_size + length) wav[0:4] = b"RIFF" wav[4:8] = struct.pack("<I", 36 + length) wav[8:12] = b"WAVE" wav[12:16] = b"fmt " wav[16:20] = struct.pack("<I", 16) wav[20:22] = struct.pack("<H", format_code) wav[22:24] = struct.pack("<H", channel) wav[24:28] = struct.pack("<I", sample_rate) wav[28:32] = struct.pack("<I", sample_rate * channel * bit_par_sample // 8) wav[32:34] = struct.pack("<H", channel * bit_par_sample // 8) wav[34:36] = struct.pack("<H", bit_par_sample) wav[36:40] = b"data" wav[40:44] = struct.pack("<I", length) wav[44:] = samples return wav def create_kvs_client(): region_name = "ap-northeast-1" return boto3.client("kinesisvideo", region_name=region_name) def create_archive_media_client(ep): region_name = "ap-northeast-1" return boto3.client("kinesis-video-archived-media", endpoint_url=ep, config=Config(region_name=region_name)) def get_media_data(arn, start_timestamp, end_timestamp): kvs_client = create_kvs_client() list_frags_ep = kvs_client.get_data_endpoint(StreamARN=arn, APIName="LIST_FRAGMENTS")["DataEndpoint"] list_frags_client = create_archive_media_client(list_frags_ep) fragment_list = list_frags_client.list_fragments( StreamARN = arn, FragmentSelector = { "FragmentSelectorType": "PRODUCER_TIMESTAMP", "TimestampRange": {"StartTimestamp": datetime.fromtimestamp(start_timestamp), "EndTimestamp": datetime.fromtimestamp(end_timestamp)} } ) sorted_fragments = sorted(fragment_list["Fragments"], key = lambda fragment: fragment["ProducerTimestamp"]) fragment_number_array = [fragment["FragmentNumber"] for fragment in sorted_fragments] print("Received fragment_number_array:" + json.dumps(fragment_number_array,default=decimal_to_int, ensure_ascii=False)) get_media_ep = kvs_client.get_data_endpoint(StreamARN=arn, APIName="GET_MEDIA_FOR_FRAGMENT_LIST")["DataEndpoint"] get_media_client = create_archive_media_client(get_media_ep) media = get_media_client.get_media_for_fragment_list(StreamARN = arn, Fragments = fragment_number_array) return media def save_audio_to_tmp(wav_audio, filename="output.wav"): with open(f"/tmp/{filename}", "wb") as out_file: out_file.write(wav_audio) def transcribe_audio_file(file_path): with open(file_path, "rb") as audio_file: return openai.Audio.transcribe("whisper-1", audio_file) def extract_json_format(input_text): input_text = f""" ## 役割 あなたは、お客さんのお問い合わせから、予約するために必要な情報を抽出し、JSON形式で出力するシステムです。 ## ルール - お客さんからのお問い合わせから、下記の5つ抽出して、例を参考にJSON形式で出力ください。 - 「名前(name)」 - 名前は、ひらがな、に変換ください。 - 「電話番号(phone_number)」 - 電話番号にハイフン(-)は必要ないので、削除してください。数字のみを出力ください。 - 「予約の日付(date)」 - 「予約の日付(day)」は、8桁の数字での日付形式(例"20231118")に変換してください。 - 今日の日付は、{current_date}、です。「予約の日付(date)」は、未来の値です。 - 例 3日と言われたら、今月の3日のことです。もし今月の3日が過ぎていれば、翌月の3日です。 - 例 5月と言われたら、今年の5月のことです。今年の5月が過ぎていれば、翌年の5月です。 - 今日の予約も可能です。 - 月曜日が週の最初で週末は土日を指します。 - 「予約時間(time)」 - 予約可能日時は、9:00 ~ 20:00です。 - 5時と言われたら、0500ではなく、1700のことです。 - 今の時間は、{current_time}、です。「予約の時間(time)」は、未来の値です。 - 今から予約も可能です。 - 「人数(number)」 - 分からない場合、値はnullにしてください。 - JSON形式以外は出力しないでください。 ## 例 {{ "name": "やまだいちろう", "phone_number": "09011111111", "date": "20231120", "time": "1430", "number": "2", }} ## お問い合わせ {input_text} """ response = openai.ChatCompletion.create( model="gpt-4-1106-preview", messages=[ {"role": "user", "content": input_text} ], response_format= { "type":"json_object" }, temperature=0, ) response_content = response["choices"][0]["message"]["content"] response_content = json.loads(response_content) print("Received extract_json_format:" + json.dumps(response_content, default=decimal_to_int, ensure_ascii=False)) return response_content def convert_reservation_info(reservation_info): # 電話番号をカンマ区切りに変換 if reservation_info.get('phone_number'): reservation_info['phone_number_convert'] = ','.join(list(reservation_info['phone_number'])) # 日付を年月日形式に変換 if reservation_info.get('date'): date = reservation_info['date'] year = date[:4] month = int(date[4:6]) day = int(date[6:]) reservation_info['date_convert'] = f"{year}年{month}月{day}日" # 時間を時分形式に変換 if reservation_info.get('time'): time = reservation_info['time'] hour = int(time[:2]) minute = int(time[2:]) if minute == 0: reservation_info['time_convert'] = f"{hour}時" else: reservation_info['time_convert'] = f"{hour}時{minute}分" print("Received convert_reservation_info:" + json.dumps(reservation_info, default=decimal_to_int, ensure_ascii=False)) return reservation_info def check_and_add_result(reservation_info): if all(reservation_info.values()): reservation_info["result"] = "success" else: reservation_info["result"] = "failure" return reservation_info def lambda_handler(event, context): print("Received event:" + json.dumps(event,default=decimal_to_int, ensure_ascii=False)) media_streams = event["Details"]["ContactData"]["MediaStreams"]["Customer"]["Audio"] stream_arn = media_streams["StreamARN"] start_time = float(media_streams["StartTimestamp"]) / 1000 end_time = float(media_streams["StopTimestamp"]) / 1000 combined_samples = create_audio_sample( get_simple_blocks(get_media_data(stream_arn, start_time, end_time))) wav_audio = convert_bytearray_to_wav(combined_samples) save_audio_to_tmp(wav_audio) transcript = transcribe_audio_file("/tmp/output.wav") transcript = str(transcript["text"]) print("Transcript result:" + transcript) reservation_info = extract_json_format(transcript) reservation_info = check_and_add_result(reservation_info) return convert_reservation_info(reservation_info)
このLambdaは、例として下記のJSONを返します。
{ "result": "success", "name": "クラスメソッド", "phone_number": "09011111111", "date": "20231128", "time": "1900", "number": "4", "phone_number_convert": "0,9,0,1,1,1,1,1,1,1,1", "date_convert": "2023年11月28日", "time_convert": "19時" }
Lambdaでは、以下の処理をしています
- KVSからメディアデータの内、録音データを取得しWAV形式に変換します
- Whisper APIで文字起こしし、GPT-4 TurboのJSONモードで予約情報を抽出します。
- 抽出データから、予約情報が全て取得できていれば、
result
キーの値がsuccess
になります。- 予約情報が全て取得できておらず、値にnullがあれば、処理は終了し、
result
キーの値がfailure
を返します。
- 予約情報が全て取得できておらず、値にnullがあれば、処理は終了し、
- Connectの音声出力用に、取得した予約情報を修正し、JSON形式で返します。
3つのキー名が〇〇_convertは、Connect音声出力用です。
今回は書いていませんが、予約情報をDynamoDBなどに書き込む処理も必要になります。
また、JSONモードでは、上記の5つのキー名である保証がないため、チェックする処理も必要ですが、今回は省略します。
KVSから、メディアデータから録音データを抽出しWAV変換するコード解説は下記をご参考ください。
Connect フロー
Connect全体のフローは、下記のとおりです。
下記のメディアストリーミングの箇所は、裏でLexを利用して、発話が終了したら、録音を終了するフローになっています。
詳細は下記の記事をご参照ください。
録音終了後、先程作成したLambdaで、録音データから文字起こしし、予約情報の抽出を行います。
後半のフローを説明します。
「コンタクト属性を確認する」でresult
キーがfailure
であれば、「プロンプトの再生」で担当者に変えるよう音声出力します。(実際には、キューを転送するブロックを作成しますが、今回は省略します。)
result
キーがsuccess
であれば、「コンタクト属性の設定」ブロックに進みます。
「コンタクト属性の設定」では、Lambdaで返すresult
以外の8つキーを保存します。
音声出力の「プロンプトの再生」ブロックに進み、下記の値を設定すると予約情報を読み上げてくれます。
お名前は、$.Attributes.name 、様、 お電話番号は、$.Attributes.phone_number_convert 、 予約日時は、$.Attributes.date_convert 、の 、$.Attributes.time_convert 、 ご予約の人数は、$.Attributes.number 、めいですね。
試してみる
電話での予約を無人対応を想定し、1回の発話で、下記の5つの予約情報を抽出できるか確認します。
- お名前
- 電話番号
- 予約日
- 予約時間
- 人数
名前は、 「すごい名前生成器」というサイトで作成された名前を利用します。
JSONモードで抽出する際のプロンプトは、下記のとおりです。
def extract_json_format(input_text): input_text = f""" ## 役割 あなたは、お客さんのお問い合わせから、予約するために必要な情報を抽出し、JSON形式で出力するシステムです。 ## ルール - お客さんからのお問い合わせから、下記の5つ抽出して、例を参考にJSON形式で出力ください。 - 「名前(name)」 - 名前は、ひらがな、に変換ください。 - 「電話番号(phone_number)」 - 電話番号にハイフン(-)は必要ないので、削除してください。数字のみを出力ください。 - 「予約の日付(date)」 - 「予約の日付(day)」は、8桁の数字での日付形式(例"20231118")に変換してください。 - 今日の日付は、{current_date}、です。「予約の日付(date)」は、未来の値です。 - 例 3日と言われたら、今月の3日のことです。もし今月の3日が過ぎていれば、翌月の3日です。 - 例 5月と言われたら、今年の5月のことです。今年の5月が過ぎていれば、翌年の5月です。 - 今日の予約も可能です。 - 月曜日が週の最初で週末は土日を指します。 - 「予約時間(time)」 - 予約可能日時は、9:00 ~ 20:00です。 - 5時と言われたら、0500ではなく、1700のことです。 - 今の時間は、{current_time}、です。「予約の時間(time)」は、未来の値です。 - 今から予約も可能です。 - 「人数(number)」 - 分からない場合、値はnullにしてください。 - JSON形式以外は出力しないでください。 ## 例 {{ "name": "やまだいちろう", "phone_number": "09011111111", "date": "20231120", "time": "1430", "number": "2", }} ## お問い合わせ {input_text} """
1つめ
モデル
:gpt-4-1106-preview、gpt-3.5-turbo-1106発話内容
:さとうかずまです。電話番号は09012345678です。明日の3時に5名で予約できますか?Whisperでの文字起こし
:佐藤一馬です。電話番号は090-12345678です。 明日の3時に5名で予約できますか?- GPT-4 Turbo JSONモードでの抽出:下記の通りです
{ "name": "さとういちま", "phone_number": "09012345678", "date": "20231121", "time": "1500", "number": "5", "phone_number_convert": "0,9,0,1,2,3,4,5,6,7,8", "date_convert": "2023年11月21日", "time_convert": "15時" }
gpt-4-1106-preview
とgpt-3.5-turbo-1106
どちらも同じ結果でした。
電話番号や予約日、時間、人数は全く問題ないですね。
ただし、「さとうかずま」という発話をWhisperでは、「佐藤一馬」と勝手に漢字に変換され、JSONモードで抽出とひらがな変換時に、「さとういちま」と誤変換されました。
Whisperでテキスト化する際に、名前をひらがなのままにできればよいのですが、Whisperのプロンプト箇所を修正しても漢字のままでした。
予約時間が3時の場合、プロンプトに記載している通り、予約可能時間が9:00~20:00なので15時と解釈してくれています。
2つめ
モデル
:gpt-4-1106-preview、gpt-3.5-turbo-1106発話内容
:やまだあいです。電話番号は012012345678です。来月の月末の火曜日の10時半にお店を利用したいです。人数は私だけです。Whisperでの文字起こし
:山田愛です 電話番号は0120-12345678です 来月の月末の火曜日の10時半にお店を利用したいです 人数は私だけです- GPT-4 Turbo JSONモードでの抽出:下記の通りです
gpt-3.5-turbo-1106
は下記です。
{ "name": "やまだあい", "phone_number": "012012345678", "date": "20231227", "time": "1030", "number": "1", "result": "success", "phone_number_convert": "0,1,2,0,1,2,3,4,5,6,7,8", "date_convert": "2023年12月27日", "time_convert": "10時30分" }
gpt-4-1106-preview
は下記です。
{ "name": "やまだあい", "phone_number": "012012345678", "date": "20231226", "time": "1030", "number": "1", "result": "success", "phone_number_convert": "0,1,2,0,1,2,3,4,5,6,7,8", "date_convert": "2023年12月26日", "time_convert": "10時30分" }
「来月の月末の火曜日」に対して、gpt-3.5-turbo-1106
は、2023年12月27日と出力していますが、間違いです。
gpt-4-1106-preview
は、2023年12月26日と出力しており、正しいです。
私と伝えると人数が1人と解釈してくれています。
結果
今回の検証での認識とその評価は、以下の結果となりました。
項目 | 認識 | 備考 |
---|---|---|
電話番号 | ◯ | 問題なく認識した |
名前 | △ | 一部誤認識あり |
予約日 | ◯ | 問題なく認識した |
予約時間 | ◯ | 問題なく認識した |
人数 | ◯ | 問題なく認識した |
gpt-4-1106-preview
の場合、電話番号、予約日、予約時間、人数に関しては、問題なく認識しました。
名前は、Whisperで勝手に漢字変換され、抽出時にひらがなに戻すため、一部で誤認識がみられました。
どなたかの参考になれば幸いです。